Skip to content

Clip markdown editor toolbar corners to the rounded outline - #5765

Open
FadhlanR wants to merge 4 commits into
mainfrom
cs-11700-body-scroll-grey-bars
Open

Clip markdown editor toolbar corners to the rounded outline#5765
FadhlanR wants to merge 4 commits into
mainfrom
cs-11700-body-scroll-grey-bars

Conversation

@FadhlanR

@FadhlanR FadhlanR commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Problem

In the rich-markdown editor, the docked toolbar (the grey "Compose / B / I / H1…" bar) is a position: sticky element with square bottom corners, living inside the editor's rounded, bordered container (.codemirror-editor). The container had a border-radius but did not clip its children.

When you scroll to the very bottom of the body field, the sticky toolbar is pushed down to the container's bottom edge, and its square grey corners spill past the rounded outline — the reported bug (CS-11700):

when user scrolls to the very end of the body field the grey UI bar's bottom corners do not conform to the rounded corners of the body field boundary outline.

Fix

Wrap the toolbar + editor mount in a new .codemirror-body element with border-radius + overflow: clip, so the editor's rounded corners clip the toolbar's square bottom corners when it docks at the bottom.

Key detail: overflow: clip, not hidden. hidden would establish a scroll container, which would break the toolbar's position: sticky (it would stick to the never-scrolling editor instead of the outer edit panel). clip rounds/clips the corners without creating a scroll container, preserving the sticky behavior.

The card-search (/) and format-picker popovers are intentionally left outside the clip wrapper (still positioned relative to .codemirror-editor), so they can continue to overflow the editor box — e.g. when you open the card search on the last line.

Tests

  • Adds an integration test in rich-markdown-field-test.gts asserting the toolbar + mount share the overflow: clip wrapper and that the wrapper nests directly in the bordered editor container.
  • Existing codemirror-embed-toolbar / markdown-embed-chooser tests continue to guard the popovers.

Verification note

Statically verified (template-lint, eslint, prettier). The corner-pixel conformance is best confirmed visually in the running app; happy to attach before/after screenshots — flag if you'd like them before this comes out of draft.

Linear: CS-11700

Screenshot

Before

Screenshot 2026-08-13 at 18 54 43

After

Screenshot 2026-08-13 at 18 54 53

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files      1 suites   1h 50m 56s ⏱️
3 642 tests 3 630 ✅ 12 💤 0 ❌
3 658 runs  3 646 ✅ 12 💤 0 ❌

Results for commit e973eda.

Realm Server Test Results

    1 files      1 suites   14m 57s ⏱️
2 174 tests 2 174 ✅ 0 💤 0 ❌
2 254 runs  2 254 ✅ 0 💤 0 ❌

Results for commit e973eda.

@FadhlanR
FadhlanR force-pushed the cs-11700-body-scroll-grey-bars branch from 0298c3b to 2afe9af Compare August 14, 2026 08:14
@FadhlanR
FadhlanR marked this pull request as ready for review August 14, 2026 09:33
@FadhlanR
FadhlanR requested a review from a team August 14, 2026 09:33

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2afe9af45b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

flex: 1;
min-height: 0;
border-radius: calc(var(--boxel-border-radius) - 1px);
overflow: clip;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude the Add-embed popover from the clipping wrapper

The new overflow: clip also clips .toolbar-embed-popover, which remains inside this wrapper and is positioned below the toolbar with top: 100%. When the outer edit panel is scrolled far enough that the sticky toolbar is constrained against the editor's bottom edge—the state this change targets—there is no room below it inside the wrapper, so opening Add embed hides part or all of the menu and can make its actions unclickable. The added test only verifies that the menu exists in the DOM, not that it is visible; the popover should escape the clipped subtree or flip above the toolbar near the bottom.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in 8a88e2f. The Add-embed popover is now position: fixed, pinned under its trigger from getBoundingClientRect() and repositioned on scroll/resize while it's open. Its containing block is the viewport, so .codemirror-body's overflow: clip can no longer reach it — the menu stays visible and clickable when the toolbar docks at the bottom. The test was inverted to assert the popover is fixed-positioned rather than merely present in the DOM.

You were right and it under-counted: the mode selector had the same problem (its menu is taller), and it's fixed too — it now renders in the shared dropdown wormhole instead of in place.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Resolved for the reason the thread named. Verified in 8a88e2f rather than from the description: the popover's containing block is the viewport, so .codemirror-body is not in its containing-block chain and its overflow: clip cannot reach the popover — this is the same rule that leaves the toolbar's tooltips unaffected (they render into #tooltip-overlay at the app root instead).

The escape works; what comes in with it is edge behaviour. _positionEmbedPopover sets top/left with no clamp and no flip, and a fixed box does not move when anything scrolls — _trackEmbedPopover re-pins it to the trigger on every scroll event, so scrolling cannot recover it either. Near the bottom of the window the menu renders below the fold and stays there. Mechanism, the ancestor that makes the fixed-position approach fragile in this app, and two ways out are on _positionEmbedPopover in the new review.

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] I reviewed this as a clipping change rather than a styling change: introducing an overflow box mid-tree changes what every positioned descendant underneath it can do, so the interesting question is not "do the corners look right" but "what else was relying on being able to escape that box". I rebuilt the box structure in Chromium from the real declarations in codemirror-editor.gts, rich-markdown.gts, ember-basic-dropdown.css, select/index.gts and markdown-editor-mode-select.gts, with variables.css loaded so the tokens resolve to their production values, and measured across the outer panel's scroll range. Every number below came out of that harness — not from reading the CSS and reasoning about it.

Bottom line: the corner fix itself is correct and the clip-not-hidden reasoning holds up under measurement, but the new clip box makes both of the toolbar's menus unusable in exactly the bottom-docked state this change exists to fix. That needs resolving before merge. The mode selector — the "Compose" control named in this change's own summary — is affected and is not mentioned anywhere in the diff, the comment, or the tests.

What lands right. The clip vs hidden choice is the load-bearing decision here and it is correct for the stated reason. Toggling only that value: with clip the toolbar holds at a constant 20px offset from the scroll panel's top edge as the panel scrolls; with hidden it runs to −119, −319, −519, −619 — straight out of the panel, because hidden makes .codemirror-body a scroll container and top: 0 then resolves against a box that never scrolls. Documenting that in the CSS is the right call, since the failure is silent and the next person to "tidy" it to hidden would have no signal. I also checked the flex chain the wrapper interposes: with the editor in normal auto-height flow, .codemirror-mount still reaches the editor's inner bottom edge exactly (gap of 0px, short content and long), so the "click anywhere below the text focuses it" affordance survives intact.

Answering the open automated-review thread on this rule: it is correct, and it under-counts. The Add-embed popover is clipped as described — I measured the menu going from 46/46px visible and both items clickable, to 30/46 with one item clickable, to 0/46 with none, across the last ~50px of scroll, with the trigger on screen and clickable throughout. The same mechanism hits the mode selector harder (94px of menu instead of 46px, and on a one-line body field the failure is ~57px of scroll away). It is not a matter of the menu being partly obscured — it is entirely absent while its trigger looks normal.

Recommendations, in priority order:

  1. Move both toolbar menus out of the clip box, or drop the clip in favour of rounding the toolbar's own bottom corners. Mechanism, measurements, and three concrete options are on the .codemirror-body rule in codemirror-editor.gts. Blocking.
  2. Correct the Add-embed paragraph in the CSS comment once (1) is settled — as written it asserts an invariant the code does not hold. See the thread on that comment block, which also keeps the clip/hidden half intact. Blocking.
  3. Invert the Add-embed test to assert the menu is not clipped, rather than asserting it sits inside the clipped subtree. See the thread on that test. Blocking, follows from (1).
  4. Add a border-radius assertion to the wrapper test — without it, deleting the radius (the declaration that makes the clip do anything visible) leaves every assertion passing and brings the reported bug straight back. See the thread on that test. Non-blocking.
  5. max(0px, calc(var(--boxel-border-radius) - 1px)) for the radius, since cards do reassign that token to values near 1px. Detail in the comment-block thread. Non-blocking.

Adjacent, out of scope — worth one manual check. The slash-command (/) menu is a CodeMirror autocompletion tooltip. codemirror-context.ts configures autocompletion({ override: [slashSource] }) with no tooltips({ parent }), so CodeMirror renders it into view.dom — the .cm-editor element, which now sits inside the new clip box. Whether it escapes turns entirely on the position CodeMirror gives it: my probe confirms a position: fixed descendant escapes this clip (containing block is the viewport) while a position: absolute one does not. I could not resolve @codemirror/view in this environment to check which it uses, so this is a check rather than a finding — open the editor, type / near the bottom of a docked body field, and confirm the menu still appears. If it does not, it belongs with the two menus in (1).

Not raising: the two new tests repeat the TestCard + setupIntegrationTestRealm boilerplate, but so does every other test in that file, so that is the file's idiom rather than something this change introduced.


Generated by Claude Code

Comment on lines +1137 to +1144
.codemirror-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border-radius: calc(var(--boxel-border-radius) - 1px);
overflow: clip;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Blocking — regression. This clip box swallows both menus that live in the toolbar, not just the Add-embed dropdown the comment above accounts for. The one that isn't mentioned anywhere in this change is the Compose / Source / Preview mode selector — the control this change's own summary names as the left end of the grey bar.

Background: what overflow: clip does and does not clip. An overflow value other than visible clips a descendant only when the clipping element sits in that descendant's containing-block chain. So a position: fixed descendant (containing block = the viewport, an ancestor of .codemirror-body) escapes; a position: absolute descendant whose containing block is inside .codemirror-body does not. I verified both directions in Chromium against this exact box structure:

positionFixed    -> probe painted below the clip box, hit test returns the probe   (escapes)
positionAbsolute -> probe below the clip box, hit test falls through to the page   (clipped)

Both toolbar menus land on the clipped side of that rule.

  1. .toolbar-embed-popoverposition: absolute, containing block .toolbar-embed-trigger (position: relative, in the toolbar). This is the case the automated reviewer's open thread on this rule flags, and it is real; see my note on the comment block just above.
  2. MarkdownEditorModeSelect — it renders BoxelSelect with @renderInPlace={{true}} (packages/base/components/markdown-editor-mode-select.gts), and rich-markdown.gts yields it into <:leadingControls>, which this template renders as the first child of .codemirror-toolbar. renderInPlace keeps .ember-basic-dropdown-content in the DOM next to its trigger, and packages/boxel-ui/src/styles/ember-basic-dropdown.css gives it position: absolute inside .ember-basic-dropdown { position: relative }. Containing block is inside the clip box → clipped.

Measured, using this repo's real CSS and tokens. I rebuilt the box structure in Chromium from the declarations in this file, rich-markdown.gts, ember-basic-dropdown.css, select/index.gts and markdown-editor-mode-select.gts, with variables.css loaded so the tokens resolve to their real values (--boxel-border-radius: 10px, --boxel-sp-xs: 9px, --boxel-sp-3xs: 5.06px, --boxel-font-size-sm: 14px). The 3-option mode menu measures 94px tall. Scrolling the outer edit panel, with a minimum-height editor (a one-line body field, editorH: 155px):

panel scrollTop room below toolbar in clip box toolbar on screen menu visible options clickable
0 118px yes 94/94 3/3
57 62px yes 64/94 2/3
86 33px yes 35/94 1/3
115 4px yes 6/94 0/3
143 0px yes 2/94 0/3

The toolbar is still fully on screen in every one of those rows — the trigger looks and behaves normally, and the menu it opens is simply not there. On a one-line body field that state is ~57px of outer-panel scroll away. With a long body the failure window is the last ~100px of scroll, i.e. precisely the bottom-docked state this change exists to fix.

Flipping the menu upward is not an escape either: ember-basic-dropdown's in-place positioning measures window.innerHeight - triggerRect.bottom, so it keys off the browser window, not this clip box. In the docked state the toolbar is near the top of the window, so it stays below and gets clipped; and if it did flip, the toolbar is pinned at the clip box's top edge, so it would be clipped upward instead.

The way out. The menus have to live outside whatever box does the clipping — any clip that contains the toolbar clips the toolbar's positioned descendants, so moving or resizing the wrapper won't help.

  • Mode selector: dropping @renderInPlace={{true}} puts the menu in #ember-basic-dropdown-wormhole, outside the clip entirely. BoxelSelect already supports this path deliberately for themed contexts — onOpen() calls startObservingTheme()syncCustomProps(), which copies --background / --foreground / --border / --primary onto the wormhole container so the card theme still applies (packages/boxel-ui/src/components/select/index.gts). Worth confirming that holds for a card-rendered field before committing to it, since packages/base/enum.gts reaches for renderInPlace too and that may encode a constraint I haven't found.
  • Add-embed popover: render it outside .codemirror-body — either {{in-element}} into a container under .codemirror-editor (which already carries position: relative), or switch it to position: fixed placed from the trigger's getBoundingClientRect(), which the probe above shows escapes the clip.
  • Cheaper fallback, if both menus must stay in place: skip the clip entirely and put border-bottom-left-radius / border-bottom-right-radius on .codemirror-toolbar itself. One line, zero risk to the menus — but the bar then carries rounded bottom corners in the undocked state too, where they read as small notches against the mount. That is a design call rather than a correctness one.

Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in 8a88e2f. Dropped @renderInPlace={{true}} from MarkdownEditorModeSelect, so BoxelSelect renders its menu in #ember-basic-dropdown-wormhole — out of the clip box entirely. BoxelSelect's onOpen still syncs the card theme onto the wormhole container, so the themed look holds. The Add-embed popover is handled separately (now position: fixed, since it's a hand-rolled div rather than a BoxelSelect).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Resolved, and the fix is better than "the themed look is preserved" — dropping @renderInPlace={{true}} is what makes the theme sync run at all. In packages/boxel-ui/src/components/select/index.gts, startObservingTheme() returns early when renderInPlace is set, and dropdownContainer resolves to the component's own element in that mode, so syncCustomProps() had nothing to do before. --boxel-form-control-border-radius is in the synced dropdownVars list, so the trigger's 2px-tighter radius reaches the menu in the wormhole.

One cosmetic drift the move introduces, non-blocking. The global block in markdown-editor-mode-select.gts sets --boxel-form-control-border-radius: calc(var(--boxel-border-radius) - 2px) on .boxel-select__dropdown.markdown-editor-mode-select-dropdown — i.e. on the menu element itself, which wins over the synced value inherited from #ember-basic-dropdown-wormhole. --boxel-border-radius is in neither themeVars nor dropdownVars, and the wormhole sits at the app root, so under a card that reassigns that token the menu's radius resolves against the app default while the trigger resolves against the theme. Dropping that one declaration from the dropdown block and letting the synced --boxel-form-control-border-radius through keeps the two in step under any theme.

Comment thread packages/base/codemirror-editor.gts Outdated
Comment on lines +1128 to +1136
/* Wraps the sticky toolbar + editor mount so the editor's rounded
corners clip the toolbar's square bottom corners when it docks at
the bottom on scroll. `overflow: clip` (not `hidden`) does this
without establishing a scroll container, so the toolbar keeps
sticking to the outer scroll panel. The Add-embed dropdown lives
inside this wrapper with the toolbar and is intentionally clipped
to it: it drops down into the tall editor mount, so the clip is
invisible in practice. The radius is inset by the 1px border so it
hugs the border's inner curve. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Two halves of this comment, pulling in opposite directions.

The clip vs hidden half is right, and worth keeping — confirmation. I verified it rather than taking it on faith, because it is the load-bearing claim of the whole change and the failure mode is silent. Same box structure in Chromium, toggling only .codemirror-body's overflow, measuring the toolbar's offset from the scroll panel's top edge as the panel scrolls:

overflow: clip     scrollTop 0/200/400/600/700 -> toolbar offset 81, 20, 20, 20, 20   (sticky holds)
overflow: hidden   scrollTop 0/200/400/600/700 -> toolbar offset 81, -119, -319, -519, -619

With hidden the toolbar scrolls straight out of the panel — it becomes a scroll container, so top: 0 resolves against .codemirror-body, which never scrolls. That is a real trap for the next person who "tidies" this to hidden, and this comment is the right place to warn them. The mechanism is stated correctly, so it survives refactors.

The Add-embed half is not accurate, and that is what makes it worth fixing rather than just rewording. "It drops down into the tall editor mount, so the clip is invisible in practice" holds only while the editor still has room below the toolbar inside the clip box. In the bottom-docked state — the exact state this change targets — that room is 0 by construction, because docking means the toolbar's bottom edge has reached the clip box's bottom edge. Measured on the same harness, the menu goes from fully clickable to fully gone across the last ~50px of scroll while the trigger stays on screen and clickable:

room below toolbar menu visible "Add a card" "Add a file"
51px 46/46 clickable clickable
31px 30/46 clickable clipped
11px 10/46 clipped clipped
1px 0/46 clipped clipped

So the automated reviewer's open thread on this rule is correct, and it under-counts: the mode selector is affected the same way and harder (94px of menu instead of 46px). Detail and fix options are in my comment on the .codemirror-body rule below. Once that's settled, this paragraph should describe whatever the new arrangement actually guarantees — as written it documents an invariant the code does not hold, which is worse than no comment, since the next reader will trust it.

Unrelated nit on the last sentence, non-blocking. calc(var(--boxel-border-radius) - 1px) goes negative — and so becomes invalid, dropping the radius to 0 — if a card theme sets --boxel-border-radius below 1px. Cards do reassign that token (packages/experiments-realm/task.gts sets it to --boxel-border-radius-xxs, ~1.5px), so the headroom here is about half a pixel. max(0px, calc(var(--boxel-border-radius) - 1px)) costs nothing and removes the cliff.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] All three parts addressed (8a88e2f, radius clamp in e973eda).

  • clip vs hidden: kept — it's the load-bearing choice and the comment still documents why hidden would break sticky.
  • Add-embed paragraph: rewritten. It no longer claims the popover is "intentionally clipped / invisible in practice" (untrue once docked); it now states both menus must escape the clip and how — the mode selector wormholes out of place, the popover is position: fixed.
  • max(0px, calc(...)): applied. Worth noting the effect is defensive rather than behavioural — a negative calc() invalidates the declaration and border-radius falls back to 0, which is the same result max(0px, …) yields — but the clamp makes the intent explicit and avoids relying on the invalid-declaration fallback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Resolved on all three counts. The rewritten paragraph now states an invariant the code holds and names the mechanism for each menu, so it survives a refactor of either one — and it keeps the clip-vs-hidden warning, which is the part a future tidy-up would otherwise walk straight into.

On the clamp: your reading is right that the observable result is unchanged — an invalid border-radius declaration is dropped and nothing else in the stylesheet sets a radius on .codemirror-body, so both paths land on 0. The value of max(0px, …) is that the declaration stays valid, so the outcome no longer depends on there being no other .codemirror-body rule to fall back through. Worth keeping for that reason alone.

Comment on lines +163 to +167
assert.strictEqual(
getComputedStyle(wrapper).overflowY,
'clip',
'the wrapper clips its overflow',
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Non-blocking, but this test cannot fail for the reason its name gives. The title promises the toolbar's corners conform to the rounded outline; the assertions check overflow-y, DOM containment, and parentage. None of those is the thing that makes corners round.

What actually does the work. overflow: clip on a square box clips nothing you can see — it only becomes a corner fix because .codemirror-body also carries border-radius: calc(var(--boxel-border-radius) - 1px) on the line right above. Delete that one declaration and every assertion here still passes, while the reported bug comes straight back: the sticky toolbar's square corners spill past the outline again. .codemirror-body appears exactly once in the stylesheet, so there is no other rule that would restore the radius — I checked.

That is the regression this test exists to catch, and it is the one it lets through.

The fix. One more assertion, alongside the overflow one. getComputedStyle resolves the calc() to a pixel value, so assert it is non-zero rather than pinning the exact number — the token is themeable per card and a hard-coded 9px would be brittle:

assert.notStrictEqual(
  getComputedStyle(wrapper).borderRadius,
  '0px',
  'the wrapper is rounded — without a radius the clip has nothing to round off',
);

Two smaller things while you are in here:

  • overflowY alone leaves overflow-x unpinned. Since the point is that neither axis may become a scroll container (that is what would break position: sticky), asserting the shorthand overflow — or both axes — matches the intent more closely.
  • The "…while keeping position: sticky resolving to the outer panel" clause in the inline comment is the single most valuable fact in this file and nothing here checks it. That is genuinely hard to assert in an integration test — it needs a scrolling ancestor and real layout — so a comment is a fair place to leave it. Worth knowing that it is a comment, not a guard.

Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Added in 8a88e2f. The wrapper test now asserts getComputedStyle(wrapper).borderRadius is not 0px (non-zero rather than a hard-coded px, since the token is themeable per card). Deleting the radius declaration now fails this test instead of leaving every assertion green.

Left the position: sticky resolution as the documented inline comment, as you suggested — it needs a scrolling ancestor and real layout to assert, which this integration test doesn't set up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Resolved. The added assertion fails for exactly the regression the thread named — .codemirror-body appears once in the stylesheet, so deleting the radius declaration leaves nothing to restore it and getComputedStyle(wrapper).borderRadius goes to 0px. Asserting non-zero rather than a pixel value is the right call given the token is themeable per card.

Agreed on leaving the sticky-resolution fact as a comment; it needs a scrolling ancestor and real layout that this test does not set up.

Still open, non-blocking: overflowY alone. The source declares overflow: clip, which sets both axes, and it is both axes that must stay out of scroll-container territory for position: sticky to keep resolving against the outer panel — so asserting the shorthand (or both axes) is what matches the intent. Changing overflow to overflow-y in the rule would leave this test green while overflow-x: visible reintroduces the corner spill horizontally.

Comment on lines +211 to +216
assert
.dom('[data-test-toolbar-embed-popover]', wrapper)
.exists(
'the Add-embed dropdown lives inside the clipping wrapper — it drops ' +
'into the tall editor mount so the clip is invisible in practice',
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Blocking, tied to the .codemirror-body thread. This test pins the wrong contract, and the assertion message states as settled fact the thing that turns out not to be true.

What it checks vs. what it claims. .exists() on a DOM query proves the popover is in the subtree. Being inside the clipped subtree is exactly the condition under which it gets clipped — so the assertion passes precisely because the menu is in the position that breaks it. The message then explains that this is fine ("it drops into the tall editor mount so the clip is invisible in practice"), which is the claim I measured and found does not hold once the toolbar docks: in that state the room below the toolbar inside the clip box is 0 by construction, the menu's visible height goes to 0, and neither "Add a card" nor "Add a file" is hit-testable while the trigger stays on screen. Numbers are in my comment on the CSS comment block in codemirror-editor.gts.

The cost of leaving it is not just a weak test — a test that asserts a bug is intended behavior makes the fix look like a regression to whoever ships it next, and they have to relitigate this thread to find out otherwise.

The way out, once the .codemirror-body thread lands: invert it. Assert the menu is not clipped, which is the property anyone actually cares about, and which stays true no matter which of the fix options you pick — wormhole, portal, or position: fixed:

await click('[data-test-toolbar="add-embed"]');
let popover = root.querySelector('[data-test-toolbar-embed-popover]');
let clipBox = root.querySelector('[data-test-codemirror-body]');
assert.ok(
  !clipBox.contains(popover),
  'the Add-embed menu renders outside the clipping wrapper so it stays visible when the toolbar docks at the bottom',
);

If instead you go with the cheap fallback (rounding .codemirror-toolbar's own bottom corners and dropping the clip entirely), this test has nothing left to guard and should just go.

One thing that is right here regardless: the comment on the line above — "No embed is referenced yet, so the toolbar shows the Add-embed trigger" — explains why [data-test-toolbar="add-embed"] and not edit-embed is present, which is a genuinely non-obvious branch in this template ({{#if this._currentBfmRef}}). Keep that sentence wherever this assertion ends up.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Inverted in 8a88e2f, renamed to "…escapes the corner-clipping wrapper so it stays visible when the toolbar docks at the bottom". It no longer asserts the popover lives inside the clip box; it asserts the popover is position: fixed — the property that lets it escape the clip.

One deviation from the suggested !clipBox.contains(popover): I fixed the popover with position: fixed rather than DOM-relocating it, so it's still a descendant of .codemirror-body and contains() would stay true. Asserting position: fixed is the escape contract for this approach. Kept the "no embed referenced yet" comment above the trigger click.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Resolves the blocking half — the test no longer asserts the bug is intended behaviour, which was the cost worth avoiding. The deviation from !clipBox.contains(popover) is fair: with position: fixed the popover is still a DOM descendant, so containment is the wrong property to check.

What replaced it cannot fail, though. _positionEmbedPopover writes the literal string position: fixed; top: …; left: … into the element's inline style attribute, so getComputedStyle(popover).position reads fixed no matter what the stylesheet says and no matter where the menu actually lands. Delete .toolbar-embed-popover { position: fixed } from the CSS and this still passes; write coordinates that put the menu off-screen and it still passes. The assertion is reading back a value the component just hardcoded.

What is worth pinning is the geometry — that the menu sits under its trigger — which an integration test can check directly, and which fails for every way this can actually break:

let triggerRect = root
  .querySelector('[data-test-toolbar="add-embed"]')
  .getBoundingClientRect();
let menuRect = popover.getBoundingClientRect();
assert.ok(
  Math.abs(menuRect.top - (triggerRect.bottom + 4)) < 2 &&
    Math.abs(menuRect.left - triggerRect.left) < 2,
  'the Add-embed menu is pinned directly under its trigger',
);

Non-blocking. Note that if the popover moves onto BoxelDropdown — one of the options in the new review comment on _positionEmbedPopover — this test changes shape again, so it may be worth settling that first.

FadhlanR and others added 2 commits August 18, 2026 20:17
The rich-markdown editor's docked toolbar is a position: sticky bar with
square bottom corners living inside the editor's rounded, bordered
container. Because the container did not clip its children, scrolling to
the bottom of the body pushed the sticky toolbar down to the container's
bottom edge, where its square grey corners spilled past the rounded
outline.

Wrap the toolbar and editor mount in a `.codemirror-body` element with
`overflow: clip` so the rounded corners clip the toolbar's bottom corners
when it docks. `clip` (not `hidden`) avoids establishing a scroll
container, so the toolbar keeps sticking to the outer scroll panel. The
card-search and format-picker popovers stay outside this wrapper so they
can still overflow the editor box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correct the .codemirror-body comment to state that the Add-embed
dropdown is intentionally clipped inside the wrapper, while the
card-search popup and format picker stay outside it. Add an
integration test asserting the dropdown opens inside the clipping
wrapper so the contract can't silently drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FadhlanR
FadhlanR force-pushed the cs-11700-body-scroll-grey-bars branch from 2afe9af to c7ce9a8 Compare August 18, 2026 13:19
FadhlanR and others added 2 commits August 18, 2026 21:34
The `overflow: clip` corner-clip on `.codemirror-body` also clipped the
toolbar's two dropdown menus. When the sticky toolbar docks at the bottom
there is no room left below it inside the clip, so both menus were
swallowed while their triggers still looked normal.

Move both out of the clip: the mode selector wormholes out of place
(BoxelSelect without renderInPlace) and the Add-embed popover is now
position: fixed, pinned under its trigger and repositioned on scroll.
Correct the CSS comment that documented the old clipped behaviour, invert
the Add-embed test to assert the menu escapes the clip, and add a
border-radius assertion so deleting the radius fails the wrapper test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@FadhlanR

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Thanks for the review — all three blocking items and both non-blocking ones are addressed. Pushed in 8a88e2f (menus out of the clip + comment + tests) and e973eda (radius clamp).

Summary of the fix. Both toolbar menus now escape the corner-clip box:

  • Mode selector — renders in the shared dropdown wormhole (BoxelSelect without renderInPlace), theme still synced on open.
  • Add-embed popover — position: fixed, pinned under its trigger and repositioned on scroll/resize.

The CSS comment no longer documents the old clipped-popover behaviour, the Add-embed test asserts the menu escapes the clip (not that it sits inside it), and the wrapper test now fails if the radius is removed.

On the slash-command menu (the "worth one manual check" item): resolved by reading the source rather than by hand. CodeMirror's tooltip config defaults to position: "fixed" on non-iOS (@codemirror/view 6.43.1), and the autocompletion() here sets no tooltips({ position }) override — so the slash menu is already fixed-positioned and escapes this clip by the same mechanism as the Add-embed popover. No change needed. (It also independently corroborates the position: fixed choice — CodeMirror uses exactly this for editor tooltips inside clipping/scrolling containers.)

Note: I verified prettier, ember-tsc, and template-lint locally; the host integration suite runs in CI here.

@richardhjtan richardhjtan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Re-reviewed as a continuity pass over 8a88e2f and e973eda: for each thread, did the change fix the mechanism the thread named, and did the fix bring its own cost? I re-read the escape paths in packages/boxel-ui/src/components/select/index.gts, .../dropdown/index.gts and .../tooltip/index.gts against the new box structure rather than taking the replies' descriptions of them.

Bottom line: all three blocking items are genuinely resolved — both toolbar menus now escape the clip by real mechanisms, not by wording. One new issue comes in with the fix: the position: fixed popover has no viewport-edge flip, so near the bottom of the window it renders below the fold and, because it re-pins on every scroll, cannot be scrolled into view. That plus a test that cannot fail are the only things left, and neither blocks on its own.

What lands right.

  • The mode selector's route out is the strongest part of the change. Dropping @renderInPlace={{true}} does not merely preserve the theme — it is what enables the theme sync: startObservingTheme() returns early when renderInPlace is set, and dropdownContainer resolves to the component's own element in that mode, so the sync was a no-op before. --boxel-form-control-border-radius is in the synced dropdownVars list, so the trigger's 2px-tighter radius reaches the menu.
  • The toolbar's tooltips were never at risk and it is worth recording why, since it looks like a third menu that should have been in scope: Tooltip renders into #tooltip-overlay at the app root via {{#in-element}}, outside the clip box entirely.
  • The CSS comment on .codemirror-body now describes an invariant the code actually holds, and states the clip-vs-hidden mechanism rather than the symptom — it will still be true after the next refactor of this file.
  • The slash-menu question is answered correctly and by the right method (reading @codemirror/view's tooltip default rather than eyeballing the app).

Recommendations, in priority order:

  1. Give the Add-embed popover viewport-edge handling, or move it onto BoxelDropdown — the same wormhole escape the mode selector now uses, already imported from packages/base in three other files. Mechanism, the backdrop-filter ancestor that makes the fixed-position approach fragile, and a clamp snippet are on _positionEmbedPopover in codemirror-editor.gts. Non-blocking, but the bottom-edge case is the one I would not ship as-is.
  2. Assert the popover's geometry rather than its position. The current assertion is satisfied by the inline style attribute alone, so it passes with the stylesheet rule deleted and with wrong coordinates. Snippet in that thread. Non-blocking.
  3. The wrapper test still pins overflow-y only. overflow: clip in the source sets both axes, and it is both axes that must not become a scroll container, so the shorthand matches the intent. Non-blocking.
  4. Cosmetic drift from the wormhole move: the dropdown's own rule sets --boxel-form-control-border-radius: calc(var(--boxel-border-radius) - 2px) on the menu element, which overrides the synced value, and --boxel-border-radius is in neither sync list — so under a card theme that reassigns that token the menu's radius follows the app default while the trigger follows the theme. Detail in the mode-selector thread. Non-blocking.

Adjacent, out of scope. The Add-embed menu has no outside-click dismissal, and did not before this change either. It is a papercut for an in-flow box and slightly worse for a viewport-pinned one; it disappears on its own if (1) goes the BoxelDropdown route.


Generated by Claude Code

Comment on lines +544 to 549
_positionEmbedPopover = () => {
let trigger = this._embedTriggerEl;
if (!trigger) return;
let r = trigger.getBoundingClientRect();
this._embedPopoverStyle = `position: fixed; top: ${r.bottom + 4}px; left: ${r.left}px;`;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] New finding — regression plus drift-by-duplication. The popover has no viewport-edge behaviour, and it hand-rolls a third positioning mechanism alongside two the repo already ships. The clip-escape half works; this is about what the chosen escape costs.

Near the window's bottom or right edge the menu is unreachable. _positionEmbedPopover writes top: ${r.bottom + 4}px; left: ${r.left}px with no clamp and no flip. A position: fixed box is laid out against the viewport, so it does not move when anything scrolls — and _trackEmbedPopover re-pins it to the trigger on every scroll event, so scrolling cannot bring it back either. When the toolbar's bottom edge sits within the menu's height of the window bottom, "Add a card" / "Add a file" render below the fold and stay there for as long as the menu is open. An absolutely positioned box does not have that property: it moves with the scroll flow, so the outer panel can scroll it into view. Same argument for left plus min-width: 140px against the right edge, where the overflow also reaches the document.

The mechanism is already implemented twice in this repo, both times with edge handling. BoxelDropdown (packages/boxel-ui/src/components/dropdown/index.gts) renders into #ember-basic-dropdown-wormhole — the same escape MarkdownEditorModeSelect now uses — and is already imported from packages/base by date-range-field.gts, system-card.gts and realm.gts, so it is reachable from here without new plumbing. Tooltip (packages/boxel-ui/src/components/tooltip/index.gts) is the other: Velcro (floating-ui) positioning into #tooltip-overlay, which is why the toolbar's tooltips are untouched by the new clip box. Either one flips near the viewport edge on its own. Moving the popover onto BoxelDropdown would delete _embedPopoverStyle, _embedTriggerEl, _positionEmbedPopover, _trackEmbedPopover, both window listeners, and the htmlSafe inline-style attribute — and remove the need for the clamp in the first place.

Why the containing-block reliance is worth trading away. position: fixed escapes overflow: clip only while no ancestor establishes a containing block for fixed-position descendants. That is not hypothetical in this app: .interact-submode (packages/host/app/components/operator-mode/interact-submode.gts) sets backdrop-filter: blur(10px) saturate(160%) under .has-expanded-card, and backdrop-filter creates exactly such a containing block, so whenever a stack item is expanded the popover's top/left resolve against that element's padding box rather than the viewport. It happens to be harmless today only because .interact-submode is height: 100% inside a viewport-filling .operator-mode, putting its origin at the viewport origin — the coordinates line up by coincidence, not by contract. The day that element gains a top offset, or any ancestor picks up transform / filter / contain: paint, the menu mis-positions with nothing failing anywhere. A wormholed menu leaves the subtree outright and has no such dependency.

If the hand-rolled version stays, the minimum is a flip in the same function:

let r = trigger.getBoundingClientRect();
let h = this._embedPopoverEl?.offsetHeight ?? 0;
let top = r.bottom + 4 + h > window.innerHeight ? r.top - 4 - h : r.bottom + 4;
let left = Math.min(r.left, window.innerWidth - 140 - 8);
this._embedPopoverStyle = `position: fixed; top: ${top}px; left: ${left}px;`;

Pre-existing, now load-bearing. Nothing dismisses this menu on an outside click — _embedPopoverOpen only flips on the trigger and on choosing an item, which was true before this change as well. It reads as a papercut while the menu is an in-flow box; it reads worse once the menu is pinned to the viewport and follows the trigger through every scroll. BoxelDropdown gets outside-click dismissal for free.

Class: regression (edge behaviour) + drift-by-duplication. Non-blocking as a whole; the bottom-edge case is the piece I would not ship without either the clamp or the dropdown swap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants